package com.xtracoder.maven.extenstions;

import java.io.File;
import java.io.IOException;
import java.nio.file.FileSystems;
import java.nio.file.Files;
import java.nio.file.Path;
import java.nio.file.attribute.BasicFileAttributes;
import java.util.Arrays;
import java.util.HashMap;
import java.util.LinkedList;
import java.util.List;
import java.util.Map;
import org.apache.maven.artifact.repository.ArtifactRepository;
import org.apache.maven.execution.MavenSession;
import org.apache.maven.model.ActivationProperty;
import org.apache.maven.model.Build;
import org.apache.maven.model.Dependency;
import org.apache.maven.model.Model;
import org.apache.maven.model.Parent;
import org.apache.maven.model.Profile;
import org.apache.maven.model.building.FileModelSource;
import org.apache.maven.model.building.ModelProblemCollector;
import org.apache.maven.model.building.ModelProcessor;
import org.apache.maven.model.building.ModelSource;
import org.apache.maven.model.profile.ProfileActivationContext;
import org.apache.maven.model.profile.activation.ProfileActivator;
import org.codehaus.plexus.component.annotations.Component;
import org.codehaus.plexus.component.annotations.Requirement;
import org.codehaus.plexus.interpolation.util.StringUtils;
import org.codehaus.plexus.logging.Logger;

/**
 * 
 */
@Component( role = ProfileActivator.class, hint = "onchange", instantiationStrategy="" )
public class OnChangeProfileActivator implements ProfileActivator 
{
    @Requirement
    private Logger logger;
    
    @Requirement
    private ModelProcessor modelProcessor;
    
    // Let's collect not more then given limit - this will improve response time
    // on not scanning whole src sub-tree where as we'll output into log not more
    // than given number of files.
    public final static int MAX_CHANGED_FILES = 5;
    
    public OnChangeProfileActivator() {
    }

    @Override
    public boolean isActive(Profile profile, ProfileActivationContext context, ModelProblemCollector problems) {
        if( (profile.getActivation() == null) || (profile.getActivation().getProperty() == null) ) {
            return false;
        }
        
        ActivationProperty aproperty = profile.getActivation().getProperty();
        String profileCondition;
        Boolean enableOnHasChanges;
        Boolean skip = null;
        
        // this activator works only when explicitly ...
        //   enabled:   there is activation property onHasChanges
        //   disabled:  there is activation property onNoChanges
        //
        // Missing or invalid value means there is no configuration for this
        // activator and this means it is not active.
        if( !validActivationValues.contains(aproperty.getName()) )
            return false;
        
        profileCondition = aproperty.getName();
        enableOnHasChanges = profileCondition.equals("onHasChanges");

        if( aproperty.getValue() != null ) {
            String instructions[] = aproperty.getValue().split("[,;]");
            
            for( String instruction: instructions ) {
                String parts[] = aproperty.getValue().split(":", 2);
                String name = parts[0].toLowerCase();
                String value = (parts.length > 1) ? parts[1] : null;
                
                switch( name ) {
                    case "skip-project":
                        skip = Boolean.valueOf(value);
                        break;
                        
                    default:
                        logger.warn(String.format("Unknown processing instruction for profile %s: %s",
                               profile.getId(), instruction));
                }
            }
        }
        
        try {
            File pomDir = context.getProjectDirectory();
            
            // i beleive this happens for dependencies
            if( pomDir == null )
                return false;
            
            String pomDirPath = pomDir.toString();
            Model pom = getPom(pomDir);
            File targetFile = getTargetFile(pomDir, pom, TargetFileKind.TargetDir);
            File targetMvnFile = getTargetFile(pomDir, pom, TargetFileKind.MvnRepo);
            boolean enabled;
            List<File> changedFiles = null;
            boolean hasChanges;
            String reason;

            // This activator works only if it possible to get name of target file, 
            // this is typically possible for packaging = jar | war | bundle
            if( (targetFile == null) && (targetMvnFile == null) )
                return false;

            if( OnChangeProfileActivatorLifeCycle.goals.contains("clean") ) {
                hasChanges = true;
                reason = "clean build - ";
            }
            else if( (targetFile != null) && !targetFile.exists() ) {
                hasChanges = true;
                reason = "no target - ";
            }
            else if( ((targetFile = targetMvnFile) != null) && !targetMvnFile.exists() ) {
                hasChanges = true;
                reason = "no target - ";
            }
            else {
                changedFiles = getChangedFiles(pomDir, pom, targetFile);
                hasChanges = (changedFiles.size() > 0);
                reason = "changes vs. ";
            }

            enabled = (enableOnHasChanges == hasChanges);
            
            if( enabled ) {
                String format = "%-30s %-13s - %s";
                String targetRelPath = getRelPath(targetFile, pomDirPath);
                
                if( enableOnHasChanges ) {
                    logger.warn(String.format(format,
                        pom.getArtifactId(), "[hasChanges]", 
                        "profile:" + profile.getId() + " (" + reason + targetRelPath + ")"));

                    if( changedFiles != null ) {
                        for( int i = 0; i < changedFiles.size(); i++ ) {
                            File file = changedFiles.get(i);
                            String relPath = getRelPath(file, pomDirPath);

                            logger.warn(String.format(format, "", "", relPath));
                        }
                    }
                }
                else {
                    logger.info(String.format(format,
                        pom.getArtifactId(), "[noChanges]", 
                        "profile:" + profile.getId() + " (vs. " + targetRelPath
                            + ((targetMvnFile != null) ? " && " + targetMvnFile : "" )
                            + ")"));
                }
            }

            if( !enabled ) {
                // this overrides activeByDefault for the cases when profile 
                // needs to be enabled by default when this extention is not installed
                profile.getActivation().setActiveByDefault(false);
            }
            else {
                if( skip != null ) {
                    OnChangeProfileActivatorLifeCycle.projectsToSkip.put(
                        context.getProjectDirectory(), skip);
                }
            }
            
            changedArtifacts.put(getArtifactKey(pom), hasChanges);
                
            return enabled;
        }
        catch (Exception e) {
            logger.error(String.format("Failed at %s - %s: %s.",
                profile.getId(), context.getProjectDirectory(), e.getMessage()), e);
            return false;
        }
    }
    
    final Map<File, Model> poms = new HashMap<>();
    
    public Model getPom(File pomDir) throws IOException {
        Model pom = poms.get(pomDir);
        
        if( pom == null ) {
            Map<String, Object> options = new HashMap<>();
            File pomFile = new File(pomDir, "pom.xml");
            ModelSource modelSource = new FileModelSource( pomFile );

            if( !pomFile.exists() )
                return null;
            
            options.put( ModelProcessor.IS_STRICT, Boolean.valueOf( false ) );
            options.put( ModelProcessor.INPUT_SOURCE, null );
            options.put( ModelProcessor.SOURCE, modelSource );

            pom = modelProcessor.read( modelSource.getInputStream(), options );
            pom.setPomFile(pomFile);
            poms.put(pomDir, pom);
        }

        return pom;
    }
    
    public static String getRelPath(File path, String basePath) {
        return path == null ? "" : getRelPath(path.toString(), basePath);
    }

    public static String getRelPath(File path, File basePath) {
        return path == null || basePath == null ? "" : getRelPath(path.toString(), basePath.toString());
    }
    
    public static String getRelPath(String path, String basePath) {
        if( path.startsWith(basePath) )
            return path.substring(basePath.length() + 1);
        
        return path;
    }
    
    public void fillArtifactId(Model pom, ArtifactId id) throws IOException {
        while( pom != null  ) {
            if( id.groupId == null )
                id.groupId = pom.getGroupId();
            if( id.artifactId == null )
                id.artifactId = pom.getArtifactId();
            if( id.version == null )
                id.version = pom.getVersion();
            
            if( id.groupId != null && id.artifactId != null && id.version != null )
                break;
            
            Parent parent = pom.getParent();
            
            if( parent == null )
                break;
            
            File parentPomFile = new File(pom.getPomFile().getParentFile(), parent.getRelativePath());
            File parentDir;
            
            parentPomFile = parentPomFile.getCanonicalFile();
            if( parentPomFile.getName().equalsIgnoreCase("pom.xml") ) {
                parentDir = parentPomFile.getParentFile();
            }
            else {
                parentDir = parentPomFile;
                parentPomFile = new File(parentDir, "pom.xml");
            }
                
            if( !parentPomFile.exists() )
                break;
            
            pom = getPom(parentDir);
        }
        
        if( id.version == null )
            throw new RuntimeException(String.format(
                "Failed to identify version for %s", pom.getArtifactId()));
    }

    static class ArtifactId {
        String groupId;
        String artifactId;
        String version;
    }
    
    public enum TargetFileKind {
        TargetDir,
        MvnRepo
    }
    
    /**
     * 
     */
    public File getTargetFile(File pomDir, Model pom, TargetFileKind kind) throws IOException {
        String packaging = pom.getPackaging();
        ArtifactId ai = new ArtifactId();
        
        fillArtifactId(pom, ai);
        
        Build pomBuild = pom.getBuild();
        String finalName = (pomBuild == null) ? null : pomBuild.getFinalName();
        String mvnRepoName = ai.artifactId + "-" + ai.version;
        String baseFileName;
        File targetDir;
        
        switch( kind ) {
            case MvnRepo: {
                if( !OnChangeProfileActivatorLifeCycle.goals.contains("install") )
                    return null;
                
                MavenSession session = OnChangeProfileActivatorLifeCycle.session;
                ArtifactRepository localRepo = session.getLocalRepository();
                String repoDir = localRepo.getBasedir();
                
                baseFileName = mvnRepoName;
                targetDir = new File(repoDir, 
                        (ai.groupId == null) 
                            ? "" 
                            : ai.groupId.replace('.', '/') 
                              + '/' + pom.getArtifactId() 
                              + '/' + ai.version + '/');
                break;
            }
                
            case TargetDir:
                baseFileName = (finalName != null) && (finalName.length() > 0)
                    ? finalName 
                    : ai.artifactId + "-" + ai.version;
                targetDir = new File(pomDir, "target/");
                break;
                
            default:
                throw new IllegalArgumentException(String.format(
                        "Unsupported TargetFileKind %s", kind));
        }
        
        File targetFile;
        
        switch (packaging) {
            case "war":
                targetFile = new File(targetDir, baseFileName + ".war");
                break;
                
            case "pom":
                // POMs do not have outputs into local target dir
                if( kind == TargetFileKind.TargetDir )
                    return null;
                
                targetFile = new File(targetDir, baseFileName + ".pom");
                break;

            case "jar":
            case "bundle":
            default:
                targetFile = new File(targetDir, baseFileName + ".jar");
                break;
        }
        
        return targetFile;
    }
    
    public List<File> getChangedFiles(File pomDir, Model pom, File targetFile) throws IOException {
        List<File> changedFiles = new LinkedList<>();
        File pomFile = new File(pomDir, "pom.xml");
        
        if( (targetFile == null) || (pomFile.lastModified() > targetFile.lastModified()) ) {
            // changing the pom.xml is enough to trigger the build,
            // let's not waste time to scan othe files
            changedFiles.add(pomFile);
            return changedFiles;
        }
        
        listChangedFiles(new File(pomDir, "src"), changedFiles, targetFile.lastModified());
        
        List<Dependency> dependencies = pom.getDependencies();
        
        for( Dependency dep: dependencies ) {
            String key = getArtifactKey(dep);
            Boolean changed = changedArtifacts.get(key);
            
            if( (changed != null) && changed ) {
                changedFiles.add(new File("dependency:" + key));
            }
        }
        
        return changedFiles;
    }

    public void listChangedFiles(File dir, List<File> changedFiles, long after) {
        if( changedFiles.size() >= MAX_CHANGED_FILES )
            return;
        
        File files[] = dir.listFiles();
        
        if( files == null )
            return;
        
        for( File file: files ) {
            if( file.isDirectory() ) {
                listChangedFiles(file, changedFiles, after);
                if( changedFiles.size() >= MAX_CHANGED_FILES )
                    return;
            }
            else {
                try {
                    Path path = FileSystems.getDefault().getPath(file.getAbsolutePath());
                    BasicFileAttributes attr = Files.readAttributes(path, BasicFileAttributes.class);

                    // File can be modified in-place - this will change 'modifiedTime',
                    // but it may also be copied from another place - this will change
                    // 'creationTime', but should be treated as mosification of source 
                    // files as well.
                    if( attr.creationTime().toMillis() > after || 
                        attr.lastModifiedTime().toMillis() > after )
                    {
                        changedFiles.add(file);
                        if( changedFiles.size() >= MAX_CHANGED_FILES )
                            return;
                    }
                }
                catch( Exception e ) {
                    changedFiles.add(file);
                }
            }
        }
    }

    private void dumpContext(ProfileActivationContext context) {
        Map<String, String> props = context.getSystemProperties();
        
        for( Map.Entry<String, String> entry: props.entrySet() ) {
            logger.info("(system)" + entry.getKey() + " = " + entry.getValue());
        }
        
        props = context.getUserProperties();
        for( Map.Entry<String, String> entry: props.entrySet() ) {
            logger.info("(user)" + entry.getKey() + " = " + entry.getValue());
        }
    }
    
    private final static List<String> validActivationValues =
        Arrays.asList(new String[]{ "onHasChanges", "onNoChanges" });

    private String getArtifactKey(Model pom) throws IOException {
        return String.format("%s:%s", pom.getGroupId(), pom.getArtifactId());
    }
    
    private String getArtifactKey(Dependency dep) throws IOException {
        return String.format("%s:%s", dep.getGroupId(), dep.getArtifactId());
    }
    
    private final static Map<String, Boolean> changedArtifacts = new HashMap<>();
}
